package org.moyrax.jsp.taglib;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringReader;

import javax.servlet.jsp.JspException;
import javax.servlet.jsp.JspWriter;
import javax.servlet.jsp.tagext.BodyContent;
import javax.servlet.jsp.tagext.BodyTagSupport;

import org.apache.commons.lang.StringUtils;
import org.moyrax.compressor.DefaultCompressor;
import org.moyrax.jsp.util.DomUtils;
import org.moyrax.jsp.util.ResourceUtils;
import org.springframework.util.FileCopyUtils;

/**
 * This handler allows to create tags for several DOM <code>script</code> tag
 * manipulation. It has the next features:
 *
 * - Include files from the classpath.
 * - Multiple includes.
 * - Bundle generation.
 * - Script compression.
 *
 * @author Matias Mirabelli <lumen.night@gmail.com>
 */
public class ScriptTagHandler extends BodyTagSupport {
  /** Default value for object serialization. */
  private static final long serialVersionUID = 1L;

  /**
   * Represents a standard javascript file.
   */
  private static final String MIMETYPE_SCRIPT = "text/javascript";

  /**
   * If this type is used, the <code>src<code> attribute should specify an
   * uri which has write-access where the bundle results will be written.
   *
   * The tag body should be a set of locations (classpath and standard uris,
   * one per line) which contains the scripts that will be included in the
   * bundle. The scripts will be written as their appear in the list.
   */
  private static final String MIMETYPE_BUNDLE = "bundle/javascript";

  /**
   * Allows to specify a list of scripts like is made in the bundle type, but
   * it include each resource in a new script tag instead of create a bundle.
   *
   * It's useful to avoid creating one <code>script</script> tag for file to
   * be included.
   */
  private static final String MIMETYPE_INCLUDES = "include/javascript";

  /**
   * The HTML <code>script</code> tag's src attribute.
   */
  private String src;

  /**
   * The HTML <code>script</code> tag type attribute.
   */
  private String type;

  /**
   * Path and filename of the file that will be created as the bundle.
   */
  private boolean compress;

  /**
   * Occurs when the JSP tag was opened.
   */
  public int doStartTag() throws JspException {
    try {
      final JspWriter out = pageContext.getOut();
      final String realPath = pageContext.getServletContext().getRealPath(".");

      if (this.type.equals(MIMETYPE_SCRIPT)) {
        /* Writes a standard script tag. */
        out.write(this.resolveScript(this.src, realPath));
      }
    } catch (IOException ex) {
      throw new JspException(ex);
    }

    return EVAL_BODY_BUFFERED;
  }

  /**
   * Occurs when the JSP tag was closed.
   */
  public int doEndTag() throws JspException {
    try {
      final String realPath = pageContext.getServletContext().getRealPath(".");
      final BodyContent body = this.getBodyContent();
      final JspWriter out = body.getEnclosingWriter();

      if (this.type.equals(MIMETYPE_BUNDLE)) {
        /* Creates and includes the bundle. */
        final String bundleData = this.resolveBundle(body.getString(), realPath);
        byte[] bundleDataBytes = bundleData.getBytes();
        String bundleScript;

        /* Output compression is enabled? */
        if (this.compress) {
          bundleDataBytes = this.compressFile(new StringReader(bundleData));
        }

        /* Checks if the bundle should be written to a file. */
        if (this.src != null) {
          final ByteArrayInputStream input = new ByteArrayInputStream(
              bundleDataBytes);

          /* Creates the file with the bundle data. */
          this.writeTranslatedFile(src, realPath, input);

          bundleScript = DomUtils.createScriptTag(this.src);
        } else {
          /* The bundle is written inside the tag's body. */
          bundleScript = DomUtils.createScriptTagWithBody(this.compress ?
              new String(bundleDataBytes) : bundleData);
        }

        out.write(bundleScript);
      } else if (this.type.equals(MIMETYPE_INCLUDES)) {
        /* Creates all the includes. */
        final String includes = this.resolveIncludes(body.getString(), realPath);

        out.write(includes);
      }
    } catch (IOException ex) {
      throw new JspException(ex);
    }

    return EVAL_PAGE;
  }

  /**
   * Returns the script source url.
   */
  public String getSrc() {
    return this.src;
  }

  /**
   * Sets the script source url.
   *
   * @param src Address from the script should be loaded.
   */
  public void setSrc(final String src) {
    this.src = src;
  }

  /**
   * Returns the script's type attribute.
   */
  public String getType() {
    return this.type;
  }

  /**
   * Sets the script's type attribute.
   *
   * @param type The new value for the type attribute.
   */
  public void setType(final String type) {
    this.type = type;
  }

  /**
   * Returns if the resource(s) should be compressed.
   */
  public String isCompress() {
    return String.valueOf(this.compress);
  }

  /**
   * Sets if the resource(s) should be compressed or not.
   *
   * @param compress If it's true, the resource(s) will be compressed.
   */
  public void setCompress(final String compress) {
    this.compress = Boolean.valueOf(compress).booleanValue();
  }

  /**
   * Takes a <code>src</code> attribute from the script bean and translates it
   * to the proper HTML <code>script</code> tag depending on the type of
   * uri.
   *
   * If the <code>src</code> attribute is a standard url, the result will be
   * a common script tag with the <code>src</code> attribute set.
   *
   * If the <code>src</code> attribute is set to a classpath resource, then this
   * method will attempt to create the same path in the web application root,
   * and next it will copy the resource to that location.
   *
   * If the web application root directory has not write access, the resource
   * will be readed in its classpath location and the data should be put in the
   * <code>script</code> tag body, inside the current page context.
   *
   * @param src  Location of the javascript file.
   * @param baseDirectory Web application root directory.
   *
   * @return Returns the <code>script</code> tag for the given source.
   * @throws IOException
   */
  private String resolveScript(final String src, final String baseDirectory)
    throws IOException {

    String result = null;

    if (ResourceUtils.isClassPathResource(src)) {
      final String classPath = StringUtils.substringAfter(src, "classpath:/");

      final InputStream input = ResourceUtils.getResourceInputStream(src);

      if (this.writeTranslatedFile(classPath, baseDirectory, input)) {
        result = DomUtils.createScriptTag(classPath);
      } else {
        final String code = new String(FileCopyUtils.copyToByteArray(input));

        result = DomUtils.createScriptTagWithBody(code);
      }

    } else {
      result = DomUtils.createScriptTag(src);
    }

    return result;
  }

  /**
   * Parses the list of specified resources in this tag, and creates a bundle
   * with all resources's content as result.
   *
   * @param bundleData  List of resources which the bundle will contain.
   * @param baseDirectory Webapp root directory, for resolving filesystem
   *                      resources.
   *
   * @return Returns the created bundle with all the required content.
   * @throws IOException
   */
  private String resolveBundle(final String bundleData,
      final String baseDirectory) throws IOException {

    final String[] files = bundleData.split("\n");
    InputStream content;
    String result = "";

    for (int i = 0, j = files.length; i < j; i++) {
      if (this.isValidUri(files[i])) {
        content = ResourceUtils.getResourceInputStream(
            files[i].trim(), baseDirectory);
        result += new String(FileCopyUtils.copyToByteArray(content)) + "\n\n";
      }
    }

    return result;
  }

  /**
   * Parses the list of specified resources in this tag, and creates a bundle
   * with all resources's content as result.
   *
   * @param includesData  List of scripts which will be included in the page.
   * @param baseDirectory Webapp root directory, for resolving filesystem
   *                      resources.
   *
   * @return Returns the created bundle with all the required content.
   * @throws IOException
   */
  private String resolveIncludes(final String includesData,
      final String baseDirectory) throws IOException {

    final String[] files = this.normalizeResourceList(includesData);
    String result = "";

    for (int i = 0, j = files.length; i < j; i++) {
      if (this.isValidUri(files[i])) {
        result += this.resolveScript(files[i], baseDirectory) + "\n";
      }
    }

    return result;
  }

  /**
   * Parse a string and extract a list of valid resource locations.
   *
   * @param resourceList Resource list as a String value.
   */
  private String[] normalizeResourceList(final String resourceList) {
    final String[] result = resourceList.split("\n");

    for (int i = 0, j = result.length; i < j; i++) {
      result[i] = result[i].replaceAll("\t", "");
      result[i] = result[i].trim();
    }

    return result;
  }

  /**
   * Checks if the specified string could be processed as a resource.
   *
   * @param uri  String to be checked.
   *
   * @return true if the string is a valid resource uri.
   */
  private boolean isValidUri(final String uri) {
    final String checkStr = uri.trim();

    return !checkStr.isEmpty() &&
           !checkStr.startsWith("//") &&
           !checkStr.startsWith("/*") &&
           !checkStr.startsWith("/**");
  }

  /**
   * Tries to write a resource to the public application root directory. It
   * will be made when a classpath resource is specified, in order to create an
   * include-like <code>script</code> tag instead of write the whole script to
   * the jsp output.
   *
   * @param src  Script <code>src</code> attribute.
   * @param baseDirectory  Directory in which the resource should be written.
   * @param input  Resource input stream.
   *
   * @return If the resource was successfully written, returns true. Otherwise
   *         returns false.
   * @throws IOException
   */
  private boolean writeTranslatedFile(final String src,
      final String baseDirectory, final InputStream input) throws IOException {

    final File directory = new File(baseDirectory);

    boolean success = false;

    if (directory.isDirectory() && directory.canWrite()) {
      final File outputFile = new File(baseDirectory + src);

      /* Removes the old file, if it exists. */
      if (outputFile.exists()) {
        outputFile.delete();
      }

      /* Checks if the directory exists and create it if not. */
      if (!outputFile.getParentFile().exists()) {
        outputFile.getParentFile().mkdirs();
      }

      final FileOutputStream output = new FileOutputStream(outputFile);

      /* Should the resource be compressed? */
      if (this.compress) {
        final InputStreamReader reader = new InputStreamReader(input);
        final ByteArrayInputStream compressedInput = new ByteArrayInputStream(
            this.compressFile(reader));

        /* Writes the compressed resource. */
        FileCopyUtils.copy(compressedInput, output);
      } else {
        FileCopyUtils.copy(input, output);
      }

      success = true;
    }

    return success;
  }

  /**
   * Compress the specified data using the default compressor and compression
   * options.
   *
   * @param reader Reader which provides the data to be compressed.
   */
  private byte[] compressFile(final Reader reader) {
    final DefaultCompressor compressor = new DefaultCompressor();

    /* The compressed result is saved to the same file. */
    compressor.munge = true;

    return compressor.compress(reader, DefaultCompressor.TYPE_JS);
  }
}
